import { dirname, join as joinPath } from "path";
import { createHash } from "crypto";
import { copyFile } from "fs/promises";
import { http, https } from "follow-redirects";
const getHttp = http.get;
const getHttps = https.get;

import { IMinecraftVersion } from "./version";
import {
    createWriteStream,
    existsSync,
    mkdirSync,
    readFileSync,
    unlinkSync,
} from "fs";
import { DownloadError } from "./error";
import Constants from "./constant";
const debug = Constants.debug;

interface IDownloadableObject {
    get url(): string;
    get sha1(): string;
    get diskLocations(): string[];

    copy(): IDownloadableObject;
}

class Library implements IDownloadableObject {
    url: string;
    sha1: string;
    private _path: string;

    constructor(url: string, path: string, sha1: string) {
        this.url = url;
        this.sha1 = sha1;
        this._path = path;
    }

    get diskLocations(): string[] {
        return [this._path];
    }

    copy() {
        return new Library(this.url, this._path, this.sha1);
    }
}

class ClientJar implements IDownloadableObject {
    version: IMinecraftVersion;
    url: string;
    sha1: string;

    constructor(url: string, sha1: string, version: IMinecraftVersion) {
        this.version = version;
        this.url = url;
        this.sha1 = sha1;
    }

    get diskLocations(): string[] {
        return [joinPath(this.version.path, `${this.version.id}.jar`)];
    }

    copy() : ClientJar {
        return new ClientJar(this.url, this.sha1, this.version);
    }
}

class AssetIndex implements IDownloadableObject {
    assetPath: string;
    id: string;
    sha1: string;
    url: string;

    constructor(id: string, sha1: string, url: string, assetPath: string) {
        this.assetPath = assetPath;
        this.id = id;
        this.sha1 = sha1;
        this.url = url;
    }

    get diskLocations(): string[] {
        return [joinPath(this.assetPath, "indexes", `${this.id}.json`)];
    }

    copy() : AssetIndex {
        return new AssetIndex(this.id, this.sha1, this.url, this.assetPath);
    }
}

class Asset implements IDownloadableObject {
    officialAssetBaseURL = "https://resources.download.minecraft.net";

    assetIndex: AssetIndex;
    name: string;
    sha1: string;

    constructor(name: string, sha1: string, assetIndex: AssetIndex) {
        this.name = name;
        this.sha1 = sha1;
        this.assetIndex = assetIndex;
    }

    get url(): string {
        return `${this.officialAssetBaseURL}/${this.sha1.slice(0, 2)}/${
            this.sha1
        }`;
    }
    get diskLocations(): string[] {
        return [
            joinPath(
                this.assetIndex.assetPath,
                "objects",
                this.sha1.slice(0, 2),
                this.sha1,
            ),
            joinPath(this.assetIndex.assetPath, "virtual", "legacy", this.name),
        ];
    }

    copy() : Asset {
        return new Asset(this.name, this.sha1, this.assetIndex);
    }
}

class Java implements IDownloadableObject {
    get url(): string {
        throw Error("java instances don't have url!");
    }
    diskLocations:string[];
    get sha1(): string {
        throw Error("java instances don't have sha1!");
    }
    
    constructor(diskLocation:string) {
        this.diskLocations = [diskLocation];
    }

    copy(): IDownloadableObject {
        return new Java(this.diskLocations[0]);
    }
}

class Sha1 {
    static check(path: string, sha1: string): boolean {
        try {
            const binary = readFileSync(path);
            return this._check(binary, sha1);
        } catch {
            return false;
        }
    }

    static _check(binary: Buffer, sha1: string): boolean {
        return this._get(binary) == sha1;
    }

    static _get(binary: Buffer): string {
        const sha1Object = createHash("sha1");
        sha1Object.update(binary);

        return sha1Object.digest("hex").toLowerCase();
    }
}

interface IMirror {
    redirect<T extends IDownloadableObject>(downloadableObject:T) : IDownloadableObject;
}

class OfficialMirror implements IMirror {
    redirect<T extends IDownloadableObject>(downloadableObject: T): T {
        return downloadableObject;
    }
}

class BMCLAPIMirror implements IMirror {
    redirect<T extends IDownloadableObject>(downloadableObjecta: T): IDownloadableObject {
        const downloadableObject:IDownloadableObject = downloadableObjecta.copy();

        if (downloadableObject instanceof Library) {
            downloadableObject.url = downloadableObject.url.replace("https://libraries.minecraft.net", "https://bmclapi2.bangbang93.com/maven");
        }

        else if (downloadableObject instanceof ClientJar) {
            downloadableObject.url = downloadableObject.url.replace("https://piston-data.mojang.com", "https://bmclapi2.bangbang93.com");
        }

        else if (downloadableObject instanceof AssetIndex) {
            downloadableObject.url = downloadableObject.url.replace("https://piston-meta.mojang.com", "https://bmclapi2.bangbang93.com");
        }

        else if (downloadableObject instanceof Asset) {
            downloadableObject.officialAssetBaseURL = "https://bmclapi2.bangbang93.com/assets";       
        }
        else {
            throw Error("unknown type of downloadable object");
        }
        return downloadableObject;
    }
}

class Downloader {
    /**
     * ensure the path exists.
     * @param path
     */
    static async createFolder(path: string): Promise<void> {
        if (!existsSync(path)) {
            mkdirSync(path, {
                recursive: true,
            });
        }
    }

    /**
     * @async
     * @param url
     */
    private static _download(url: string, path: string): Promise<void> {
        return new Promise((resolve, reject) => {
            Downloader.createFolder(dirname(path));
            let file = createWriteStream(path);
            let get;

            if (url.startsWith("http://")) {
                get = getHttp(url, (response) => {
                    response.pipe(file);
                });
            } else if (url.startsWith("https://")) {
                get = getHttps(url, (response) => {
                    response.pipe(file);
                });
            } else {
                throw new DownloadError("unsupported protocol");
            }

            get.on("error", (err) => {
                file.close();
                unlinkSync(path);
                reject(err);
            });

            file.on("finish", () => {
                file.close();
                resolve();
            });
        });
    }

    static mirror:IMirror = new OfficialMirror();

    static async download(object: IDownloadableObject): Promise<void> {
        object = Downloader.mirror.redirect(object);

        const diskLocations = object.diskLocations;
        if (diskLocations.length === 0) return;

        const downloaded: string[] = [];

        diskLocations.forEach((diskLocation: string) => {
            if (Sha1.check(diskLocation, object.sha1))
                downloaded.push(diskLocation);
        });

        let copyFrom: string = diskLocations[0];

        // copy if there is already a file ready
        if (downloaded.length > 0) copyFrom = downloaded[0];
        else {
            let retry = 6;

            while (retry > 0) {
                try {
                    await Downloader._download(object.url, copyFrom);
                    break;
                } catch (e) {
                    if (debug) console.log(`facing error when downloading ${object.url}: ${e}`);
                    if (retry == 0) {
                        throw e;
                    }
                    if (debug) console.log(`retry #${10-retry} of ${object.url}`);
                    retry--;
                }
            }
        }

        const copyPromises: Promise<void>[] = [];
        diskLocations.forEach((diskLocation) => {
            if (downloaded.indexOf(diskLocation) != -1) return; // we dont need to copy this
            copyPromises.push(
                (async function (): Promise<void> {
                    await Downloader.createFolder(dirname(diskLocation));
                    await copyFile(copyFrom, diskLocation);
                })(),
            );
        });

        await Promise.all(copyPromises);
    }

    size:number;
    queue:IDownloadableObject[]=[];
    
    count:number = 0;
    successes:number = 0;
    failures:number = 0;

    /**
     * @param size the maximium number of download tasks that can be runned at the same time.
     */
    constructor(size:number) {
        this.size = size;
    }

    /**
     * add one or multiple downloadable object(s) to the queue.
     * @param object a downloadable object.
     */
    add(object:IDownloadableObject | IDownloadableObject[]): Downloader {
        if (Array.isArray(object)) {
            object.forEach(obj => {
                this.add(obj);
            });
            return this;
        }
        this.queue.push(object);
        this.count++;
        return this;
    }

    /**
     * download all the objects in the queue.
     * @async
     * @returns list of download failures with error messages.
     */
    download(): Promise<[IDownloadableObject, any][]> {

        return new Promise<[IDownloadableObject,any][]>((resolve, reject) => {
            const failures:[IDownloadableObject, any][] = [];

            const indicate =():void => {
                console.log(`${this.successes+this.failures}=${this.successes}+${this.failures}/${this.count}`);
            };

            const put = (():Promise<void> | null => {
                const last = this.queue.pop();
    
                if (last!=undefined) {
                    return Downloader.download(last).then(() => {
                        this.successes++;
                        if (debug) indicate();
                        if (this.successes + this.failures >= this.count) return resolve(failures);
    
                        put();
                    }).catch((reason) => {
                        this.failures++;
                        if (debug) indicate();
                        failures.push([last, reason]);
                        if (this.successes + this.failures >= this.count) return resolve(failures);

                        put();
                    });
                }
                return null;
            });
    
            for (let i = 0; i < this.size; i++) {
                put();
            }
        });
    }
}

export {
    IDownloadableObject,
    Library,
    ClientJar,
    AssetIndex,
    Asset,
    Java,
    Downloader,
    OfficialMirror,
    BMCLAPIMirror,
    Sha1
};
